Ordinary
About

Java 문자열 계산기

profileordilov / 2022. 3. 27

Java 문자열 계산기는 객체지향과 테스트 코드를 연습할 때 많이 이용되는 프로젝트입니다. 간단하게 콘솔로 입출력을 받고 들어온 문자열을 이용해 숫자 계산을 하는 프로그램입니다. 객체지향적으로 구성해보려고 했을 때 어떤 고민을 했는지 공유하려합니다.

객체를 설계할 때 혼자 고민해서 만들려했지만 어떤 것이 좋은 설계인지 고민하다 시작하는 것부터 힘들었습니다. 저처럼 시작하기 어려운 분들이 봤을 때 도움이 될 수도 있는 방법을 공유하려 합니다. 이렇게 구성하라는 게 아니라 어떤 식으로 생각이 흘러갔는지 봐주시면 감사하겠습니다.

✅ 요구사항

  • 객체지향적으로 구현하기
  • +, -, *, / 연산자 계산하기
  • 연산자 우선순위 적용하기
  • 테스트 코드 구현하기
  • 맵 자료구조로 연산 결과 저장하기

객체지향적으로 구성하기

객체지향적으로 구현하기 위한 제일 첫 번째 방법은 만들려는 객체에 대해서 이해가 필요하다고 생각합니다. 실무에서도 도메인에 대한 지식이 없는데, 설계를 시작한다는 건 오로지 상상과 예측에 맡기는 것이니까요.

객체에 대해 어렴풋이만 알고 있다고 느끼면, 실제로는 어떤 식으로 구성되어 있는지 검색해보면 도움이 됩니다. 예를 들어 자동차에 대한 프로젝트를 만들어야 하는데 부품에 대해 보디, 바퀴, 엔진 밖에 모르면 객체도 본래의 역할보다 훨씬 크게 나눌 수 밖에 없습니다.

그래서 저는 계산기를 어떻게 나눠야할지 알기 위해 실제 계산기는 어떤지 찾아보게 되었습니다. 검색해보면 전자 계산기에 대한 내용을 쉽게 찾을 수 있고 구성은 CPU와 비슷하다는 걸 찾을 수 있습니다. CPU

복잡해 보일 수도 있지만 우리가 원하는 건 구성 요소와 객체 간의 관계만 보면 됩니다. CPU를 계산기라고 생각하고 Input, Output, Memory는 외부에서 계산기가 의존하는 객체로 구성할 수 있습니다. 그리고 CPU 내부나머지 구성을 그대로 가져오겠습니다.

  • Control Unit
  • Processor
  • Registers
  • Combinational Logic

흐름은 빨간 색 선이 흐름 제어이고 검은 색 선이 데이터의 이동 흐름입니다. 이 중에서 어떻게 객체끼리 메시지를 보낼지가 중요하므로 빨간색 선 위주로 생각해봤습니다.

  • Input을 받으면 CPU로 문자열을 넘겨 Control Unit에게 명령합니다.
  • Control Unit은 받은 문자열을 파싱, 검증, 다음 명령 요청, 전체적인 흐름을 맡습니다.
  • Processor는 Control Unit이 요청하는 Register에 저장이나, Logic을 처리합니다.
  • Logic이나 Register가 처리하는 연산은 한 번에 하나만 처리합니다.

우리가 구성할 패키지도 그림과 동일하게 묶을 수 있습니다.

  • Input과 Output을 I/O 패키지 안에 구성할 수 있습니다.
  • Main Memory를 Persistence 혹은 Repository 패키지 안에 구성합니다.
  • Control Unit은 전체적인 흐름을 담당하므로 Controller 패키지 등으로 칭할 수 있습니다.
  • Processor와 내부 구성은 핵심 기능이므로 Service나 Handler로 칭할 수 있습니다.

우리가 원하는 건 계산기에 대해 공부하는 게 아니라 객체지향과 테스트 코드를 공부하는 건데 과하다고 느낄 수 있습니다. 하지만 설계에서 꼼꼼하지 않으면 나중에 구현 중에 구조가 맞는지 고민하게 되고 수정할 일이 많아진다고 생각합니다. 나중에 다시 어떻게 구성할지 고민하는 것보다 확실하게 짚고 넘어가면 구성에 대한 불안은 줄어들었습니다.

객체지향적으로 구현하기

처음에 코드를 작성할 때 어떤 코드가 잘 짠 코드인지 구별하기는 힘들다고 느꼈습니다. 하지만 나쁜 코드를 발견하는 방법은 생각보다 쉬웠습니다. 우리가 아플 때도 병을 바로 알아차리지 못합니다. 다만 증상이 보이면 의심해볼 수 있습니다. 코드에도 무슨 문제인지는 잘 모르더라도, 나쁜 코드일 때 보이는 증상들이 있습니다.

객체지향 생활체조 원칙

  • 한 메서드에 오직 한 단계의 들여쓰기만 한다.
  • else 예약어를 쓰지 않는다.
  • 모든 원시값과 문자열을 포장한다.
  • 한 줄에 점을 하나만 찍는다.
  • 줄여쓰지 않는다.
  • 모든 엔티티는 작게 유지한다.
  • 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
  • 일급 컬렉션을 쓴다.
  • 게터/세터/프로퍼티를 쓰지 않는다.

이 원칙들을 지키는데 신경 썼습니다. 그러다 보니 메소드들이 더 간결해지고 책임이 적어지는 걸 확인할 수 있었습니다. 어렵게 느껴졌던 부분은 한 단계의 들여쓰기를 지키는 부분이었습니다. 반복문 안에 조건문이 있는 경우 분리하려고 하다보면 너무 과도하게 분리한 것 아닌가 하는 생각도 들었습니다. 하지만 막상 분리하다보니 분리된 기능을 책임지는 객체를 찾을 수 있었고 원칙들을 지킬 수 있었습니다.

개념적으로 알기는 하지만 실제로 어떻게 적용해야할지 감이 안오던 SOLID 원칙들이 규칙을 지키다보니 자연스럽게 적용되었습니다. 작을 수록 변경하는 이유는 하나가 되었고, 변경에 유연해졌습니다. 처음에는 하나로 처리할 수 있다고 생각했던 인터페이스들도 기능의 분할에 따라 나뉘어졌습니다.

테스트의 중요성

처음에 테스트를 작성할 때는 테스트의 중요성을 잘 알지 못했습니다. 당연하게 맞다고 생각되는 테스트들을 작성했고, 처음에 테스트를 먼저 작성해 깨질 때를 제외하고 성공만 했습니다. 중요하다고 하지만 실제로 실감하지는 못했고, 그저 습관처럼 작성했습니다.

그러다 테스트의 효과를 본 건 리팩토링을 진행할 때 였습니다. Java의 Stack 클래스를 사용하다가 ArrayDeque로 클래스만 바꿨는데 테스트가 깨졌습니다. 테스트가 실패하지 않았더라면 문제가 있다는 것도 모르고 넘어갔을 겁니다. 다른 부분들도 수정을 할 때마다 일일이 실행해보지 않고 테스트를 돌리는 것만으로도 기존까지의 안전성이 보장되는 것에 안심이 됐습니다.

예외 처리

어느 프로그램이든 예외적인 입력이 들어오기 마련이고 그에 따른 예외 처리가 필요합니다. 이 때 에러가 발생하는 곳에서 처리할지 아니면 따로 한 곳에서 관리할지 선택이 필요합니다. 위의 구조 같은 경우에서는 나름대로 명확한 답이 있었습니다. Control Unit이라는 객체에서 한 번에 관리했는데, 그 이유는 실제 구조에서도 에러가 발생했을 때 어떻게 처리할지 담당해서입니다. 이처럼 정상적인 경우가 아니라 문제가 발생하면 어느 객체에서 책임지는 게 좋을지 고민이 필요했습니다.

확장에 대한 고민

처음 작성할 때 거의 대부분의 객체에 대해서 인터페이스를 먼저 구현하고 클래스를 구현했습니다. 인터페이스를 구현해 교체가 가능해야할 것 같고, 그래야 확장 가능한 구조라고 생각해서 였습니다.

하지만 리팩토링을 하면서 오히려 만들었던 인터페이스를 줄이게 되었습니다. 객체가 확장 가능한지 고민하기 앞서서, 확장할 필요가 있는지 고민하는 것이 부족했습니다. 만약 어떻게 변경에 대비할지 고민이라면, 그보다 먼저 변경이 필요할지 생각하는 게 먼저였습니다.

정리

만약 어떻게 설계를 시작할지 고민이라면 다른 것보다 '무엇을' 만드는지에 대해 고민이 필요합니다. 무엇을 만들지 정리가 되었다면 '어떻게' 만들지 추상적인 게 아닌 정량적으로 측정가능한 규칙이 필요합니다. 마지막으로 만든 것이 좋은 설계인지 고민이 된다면 '왜' 그렇게 생각하는지에 대해 고민해보는게 좋습니다. 자기 자신도 이게 '왜' 좋은 설계인지 설명할 수 없다면 남들도 그 점을 찾아낼 수 없을테니까요.